摘要
承接 Day 24 的即時生成,今天把 Gemini 回覆存進本機,做出「語錄清單」:支援收藏、排序、搜尋、刪除、複製。
透過 localStorage 陣列與清單渲染,讓暖心語錄不只即時出現,更能持續回顧與整理。
QuoteItem = { id, text, task?, reason?, source: 'gemini'|'manual', createdAt }
假設已具備:
註:若你的命名略有不同,對應到相同職責即可。
<!-- [Day25-NEW] 語錄清單 -->
<section id="quoteWallSection" aria-labelledby="quote-wall-title" style="margin-top:1rem;">
  <h3 id="quote-wall-title">AI 語錄清單</h3>
  <div style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
    <label for="quoteSearchInput">搜尋:</label>
    <input id="quoteSearchInput" type="search" placeholder="輸入關鍵字(語錄 / 任務 / 原因)" style="min-width:240px;" />
    <label for="quoteSortSelect">排序:</label>
    <select id="quoteSortSelect">
      <option value="time_desc">依新增時間(新→舊)</option>
      <option value="time_asc">依新增時間(舊→新)</option>
      <option value="alpha_asc">依語錄字母/筆畫(A→Z)</option>
      <option value="alpha_desc">依語錄字母/筆畫(Z→A)</option>
    </select>
    <!-- 可選:匯出語錄清單 -->
    <button id="btnExportQuoteJSON" type="button">匯出語錄(JSON)</button>
  </div>
  <ul id="quoteList" aria-live="polite" style="margin-top:.5rem;"></ul>
  <p id="quoteWallFeedback" class="muted" aria-live="polite"></p>
</section>
// ===== [Day25-NEW] 語錄清單:常數、節點 =====
const QUOTE_WALL_KEY = 'quote_wall'; // Array<QuoteItem>
const quoteList = document.getElementById('quoteList');
const quoteSearchInput = document.getElementById('quoteSearchInput');
const quoteSortSelect = document.getElementById('quoteSortSelect');
const quoteWallFeedback = document.getElementById('quoteWallFeedback');
const btnExportQuoteJSON = document.getElementById('btnExportQuoteJSON');
// ===== [Day25-NEW] 語錄清單:存取工具 =====
function readQuoteWall() {
  try { return JSON.parse(localStorage.getItem(QUOTE_WALL_KEY)) || []; }
  catch { return []; }
}
function writeQuoteWall(list) {
  localStorage.setItem(QUOTE_WALL_KEY, JSON.stringify(Array.isArray(list) ? list : []));
}
function addQuoteItem({ text, task = '', reason = '', source = 'gemini' }) {
  const list = readQuoteWall();
  list.push({
    id: crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()),
    text: String(text || '').trim(),
    task: String(task || ''),
    reason: String(reason || ''),
    source,
    createdAt: Date.now()
  });
  writeQuoteWall(list);
  return list[list.length - 1];
}
// ===== [Day25-NEW] 語錄清單:篩選 + 排序 =====
function applyQuoteFilterSort(list) {
  let arr = Array.isArray(list) ? list.slice() : [];
  const q = (quoteSearchInput?.value || '').trim().toLowerCase();
  if (q) {
    arr = arr.filter(item => {
      const hay = [item.text, item.task, item.reason].join(' ').toLowerCase();
      return hay.includes(q);
    });
  }
  const sort = quoteSortSelect?.value || 'time_desc';
  switch (sort) {
    case 'time_asc':
      arr.sort((a,b)=> a.createdAt - b.createdAt);
      break;
    case 'alpha_asc':
      arr.sort((a,b)=> (a.text||'').localeCompare(b.text||''));
      break;
    case 'alpha_desc':
      arr.sort((a,b)=> (b.text||'').localeCompare(a.text||''));
      break;
    case 'time_desc':
    default:
      arr.sort((a,b)=> b.createdAt - a.createdAt);
      break;
  }
  return arr;
}
// ===== [Day25-NEW] 語錄清單:渲染 =====
function renderQuoteWall() {
  const raw = readQuoteWall();
  const list = applyQuoteFilterSort(raw);
  if (!quoteList) return;
  if (!list.length) {
    quoteList.innerHTML = '<li class="muted">尚無收藏語錄。先在表單按「下次吧…」或條件分支按「我有點累…」,AI 會回一句話並自動收藏。</li>';
    return;
  }
  quoteList.innerHTML = list.map(item => `
    <li data-id="${item.id}">
      <div>
        「${escapeHTML(item.text)}」
        <small class="muted">(${new Date(item.createdAt).toLocaleString()}|來源:${item.source})</small>
      </div>
      <div class="muted">
        ${item.task ? `任務:${escapeHTML(item.task)} ` : ''}
        ${item.reason ? `原因:${escapeHTML(item.reason)} ` : ''}
      </div>
      <div style="margin-top:.25rem;">
        <button class="btn-copy"   type="button">複製</button>
        <button class="btn-remove" type="button">刪除</button>
      </div>
    </li>
  `).join('');
}
// 小工具:安全轉義(避免語錄裡出現引號/符號時破版)
function escapeHTML(s='') {
  return String(s)
    .replaceAll('&','&')
    .replaceAll('<','<')
    .replaceAll('>','>')
    .replaceAll('"','"')
    .replaceAll("'",''');
}
// ===== [Day25-NEW] 語錄清單:事件委派(複製 / 刪除) =====
quoteList?.addEventListener('click', async (e) => {
  const li = e.target.closest('li[data-id]');
  if (!li) return;
  const id = li.getAttribute('data-id');
  const list = readQuoteWall();
  const item = list.find(x => x.id === id);
  if (!item) return;
  if (e.target.closest('.btn-copy')) {
    try {
      await navigator.clipboard.writeText(item.text);
      if (quoteWallFeedback) quoteWallFeedback.textContent = '已複製到剪貼簿 ✅';
      setTimeout(()=> quoteWallFeedback && (quoteWallFeedback.textContent = ''), 1500);
    } catch {
      prompt('複製失敗,手動複製以下內容:', item.text);
    }
    return;
  }
  if (e.target.closest('.btn-remove')) {
    if (!confirm('確定要刪除這句語錄嗎?')) return;
    const kept = list.filter(x => x.id !== id);
    writeQuoteWall(kept);
    renderQuoteWall();
  }
});
// ===== [Day25-NEW] 語錄清單:搜尋 / 排序器 =====
quoteSearchInput?.addEventListener('input', () => renderQuoteWall());
quoteSortSelect?.addEventListener('change', () => renderQuoteWall());
// ===== [Day25-NEW] 語錄匯出(可選) =====
btnExportQuoteJSON?.addEventListener('click', () => {
  const list = readQuoteWall();
  if (!list.length) { 
    quoteWallFeedback.textContent = '目前沒有可匯出的語錄。';
    return;
  }
  const pretty = JSON.stringify(list, null, 2);
  triggerDownload('quote-wall.json', 'application/json;charset=utf-8', pretty);
  quoteWallFeedback.textContent = `已匯出 ${list.length} 則語錄(JSON)。`;
  setTimeout(()=> quoteWallFeedback && (quoteWallFeedback.textContent = ''), 1500);
});
// [Day13-CHANGED][Day25-NEW] 「下次吧…」:生成後自動收藏
btnLater?.addEventListener('click', async ()=>{ 
  const { task, reason } = getCurrentTaskAndReason();
  feedback.textContent = '想一想…(AI 正在回覆)';
  try {
    const line = await genQuote({ task, reason, tone: 'gentle' });
    feedback.textContent = line || '先緩一下也很好,等精神回來再開始。';
    // [Day25-NEW] 自動收藏到語錄清單
    if (line) addQuoteItem({ text: line, task, reason, source: 'gemini' });
  } catch (err) {
    feedback.textContent = '沒關係,你隨時都能回來開始。我會一直在這等你 😊';
  }
});
// [Day15-CHANGED][Day25-NEW] 「我有點累…」:生成後自動收藏
btnLater_2?.addEventListener('click', async () => {
  const { task, reason } = getCurrentTaskAndReason();
  decisionFeedback.textContent = '想一想…(AI 正在回覆)';
  try {
    const line = await genQuote({ task, reason, tone: 'reassuring' });
    decisionFeedback.textContent = line || '你已經很努力了,補個眠就是前進。';
    // [Day25-NEW] 自動收藏
    if (line) addQuoteItem({ text: line, task, reason, source: 'gemini' });
  } catch {
    decisionFeedback.textContent = '別擔心,你隨時都能回來,我一直都在 ✨';
  }
});
// [Day25-CHANGED] showPage('history') 增補:語錄清單渲染
if (page === 'history') {
  if (historySection) {
    historySection.hidden = false;
    renderMoodChartsAll().finally(() => {
      if (typeof renderHistory === 'function') renderHistory();
      // [Day25-NEW] 語錄清單也要一起渲染
      if (typeof renderQuoteWall === 'function') renderQuoteWall();
    });
    focusOrFallback(historySection, 'history-title');
  }
}